Skip to content

feat(types): add top-level skills option to ClaudeAgentOptions#804

Open
jsham042 wants to merge 5 commits intoanthropics:mainfrom
jsham042:feat/top-level-skills-option
Open

feat(types): add top-level skills option to ClaudeAgentOptions#804
jsham042 wants to merge 5 commits intoanthropics:mainfrom
jsham042:feat/top-level-skills-option

Conversation

@jsham042
Copy link
Copy Markdown

@jsham042 jsham042 commented Apr 9, 2026

Summary

Adds skills: list[str] | Literal["all"] | None to ClaudeAgentOptions as the single place to enable Skills for the main session, mirroring the existing AgentDefinition.skills field for subagents (added in #684).

Today, enabling Skills requires two non-obvious steps in unrelated fields:

options = ClaudeAgentOptions(
    allowed_tools=["Skill"],              # easy to forget
    setting_sources=["user", "project"],  # otherwise Skills are never loaded
)

With this change:

ClaudeAgentOptions(skills="all")            # every discovered Skill
ClaudeAgentOptions(skills=["pdf", "docx"])  # named subset only
ClaudeAgentOptions()                         # default: Skills off

Users no longer put "Skill" in allowed_tools. The old way still works and is unchanged.

Behavior

When skills is set, two things happen:

1. CLI flags (works on all CLI versions): the transport layer computes effective allowed_tools and setting_sources at command-build time:

  • "all" → appends bare Skill to --allowedTools.
  • [name, ...] → appends Skill(name) for each entry.
  • setting_sources is None → defaults to ["user", "project"] so installed SKILL.md files are discovered.
  • The caller's options object is never mutated, existing allowed_tools entries are preserved, explicit setting_sources take precedence, and duplicate Skill(name) entries are not re-added.

2. initialize control request (forward-compatible): the value is also forwarded as {"skills": ...} on the SDK initialize request. A supporting CLI uses this to filter which Skills are loaded into the system prompt (not just permission-gated). Older CLIs ignore unknown initialize fields, so this degrades to permission-layer gating only.

skills=None (default) is a complete no-op.

Scope: context filter, not sandbox

skills=[...] controls what the model sees and can invoke. Unlisted Skills are hidden from the listing and Skill(name) calls for them are not in allowedTools. It does not restrict filesystem access: a session with Read/Bash can still open .claude/skills/** directly. This matches the existing AgentDefinition.skills semantics for subagents. For hard isolation, use a local plugin with setting_sources=None, or permission deny rules. The docstring and the agent-sdk/skills docs page (companion CLI PR) call this out explicitly.

Companion PR

CLI-side prompt filtering: anthropics/claude-cli-internal#27911

Related issues

Test plan

tests/test_transport.py — 8 tests covering None / "all" / named / [] / merge / override / no-mutation / idempotence.
tests/test_query.py — 2 tests covering skills presence/absence in the initialize payload.

All 469 tests pass; ruff and mypy clean.

Adds a `skills: list[str] | None` field to ClaudeAgentOptions that mirrors
the existing field on AgentDefinition. When set, the SDK automatically:

- Adds `Skill` (or `Skill(name)` patterns for specific names) to the
  `--allowedTools` CLI flag.
- Defaults `setting_sources` to `["user", "project"]` when not
  already configured, so installed SKILL.md files are discovered.

Previously, enabling skills required both "Skill" in allowed_tools and an
explicit setting_sources list — a footgun the SDK can easily remove.

The existing `allowed_tools` and `setting_sources` fields are unchanged
and still take precedence when the caller sets them explicitly. The options
object itself is never mutated.
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 9, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@dfbd7d3). Learn more about missing BASE report.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #804   +/-   ##
=======================================
  Coverage        ?   84.47%           
=======================================
  Files           ?       14           
  Lines           ?     2506           
  Branches        ?        0           
=======================================
  Hits            ?     2117           
  Misses          ?      389           
  Partials        ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

In addition to translating `skills` into `Skill(name)` allowedTools
entries, also forward the list on the SDK `initialize` control request.
A supporting CLI can use this to filter which skills are loaded into the
system prompt (not just permission-gated). Older CLIs ignore unknown
initialize fields, so this is forward-compatible.
@jsham042
Copy link
Copy Markdown
Author

jsham042 commented Apr 9, 2026

CLI-side companion that honors initialize.skills for load-time filtering: anthropics/claude-cli-internal#27911. Once that lands and the bundled CLI is bumped, skills=['pdf'] will also remove non-matching skills from the model's prompt (not just permission-gate them).

jsham042 added 2 commits April 9, 2026 20:18
Unlisted skills are hidden from the listing and blocked at the Skill
tool, but their files remain readable via Read/Bash. Document the
boundary and the alternatives (local plugin, deny rules) for users who
need hard isolation.
API design refinement: skills is now the one place to enable skills
(users should not put 'Skill' in allowed_tools directly).

  None       - skills off (default)
  'all'      - every discovered skill
  [name,...] - named subset only
  []         - degenerate subset; setting_sources still defaults but no
               Skill entries are added (natural list semantics)

Type widened to list[str] | Literal['all'] | None. Transport, query
init, docstring, and tests updated.
@jsham042
Copy link
Copy Markdown
Author

E2E Test Results

Ran the Python SDK PR branch (anthropics/claude-agent-sdk-python#804 @ 01f9a46) against a CLI built from this PR (3f5f215). Temp project with two skills installed (greeter, calculator), real API calls, max_turns=1.

Test script

"""E2E proof for ClaudeAgentOptions.skills (PR #804 + CLI PR #27911)."""

import asyncio
import shutil
import sys
import tempfile
import textwrap
from pathlib import Path

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query

CLI_PATH = str(
    Path.home()
    / "code/claude-cli-internal/build-ant-native/@anthropic-ai/claude-cli-native-darwin-arm64/cli"
)


def make_project() -> Path:
    root = Path(tempfile.mkdtemp(prefix="e2e-skills-"))
    for name, desc in [
        ("greeter", "Say hello in a friendly way."),
        ("calculator", "Add two numbers together."),
    ]:
        d = root / ".claude" / "skills" / name
        d.mkdir(parents=True)
        (d / "SKILL.md").write_text(
            textwrap.dedent(f"""\
                ---
                name: {name}
                description: {desc}
                ---
                # {name}
                {desc}
                """)
        )
    return root


async def run_case(label: str, cwd: Path, skills) -> str:
    opts = ClaudeAgentOptions(
        cwd=str(cwd),
        skills=skills,
        setting_sources=["project"],
        allowed_tools=[],
        max_turns=1,
        cli_path=CLI_PATH,
        system_prompt={"type": "preset", "preset": "claude_code"},
    )
    out = []
    async with asyncio.timeout(90):
        async for msg in query(
            prompt="List the names of every skill available to you in this session, one per line. If you have no skills, say 'NONE'.",
            options=opts,
        ):
            if isinstance(msg, ResultMessage):
                out.append(msg.result or "")
    return f"[{label}] skills={skills!r}\n" + "\n".join(out).strip()


async def main() -> int:
    cwd = make_project()
    print(f"project: {cwd}")
    try:
        for label, skills in [
            ("case1-none", None),
            ("case2-all", "all"),
            ("case3-subset", ["greeter"]),
        ]:
            print(await run_case(label, cwd, skills))
            print("-" * 40)
    finally:
        shutil.rmtree(cwd, ignore_errors=True)
    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))

Output

project: /var/folders/g4/71r2lv591pg3x5d8fz_xsv840000gp/T/e2e-skills-0dxn83qj
[case1-none] skills=None
update-config
keybindings-help
verify
lorem-ipsum
remember
simplify
stuck
dream
hunter
loop
schedule
claude-api
calculator
greeter
autopilot
bughunt
bughunt-lite
deep-research
plan-hunter
review-branch
antmcp-claudesec:psr
antmcp-claudesec:psrs
antmcp-claudesec:psr
antmcp-claudesec:risk-acceptance
----------------------------------------
[case2-all] skills='all'
update-config
keybindings-help
verify
lorem-ipsum
remember
simplify
stuck
dream
hunter
loop
schedule
claude-api
calculator
greeter
autopilot
bughunt
bughunt-lite
deep-research
plan-hunter
review-branch
antmcp-claudesec:psr
antmcp-claudesec:psrs
antmcp-claudesec:risk-acceptance
antmcp-claudesec:psr
----------------------------------------
[case3-subset] skills=['greeter']
greeter
----------------------------------------

Summary

skills=None and skills="all" both surface every discovered skill (bundled + project + plugin); skills=["greeter"] filters the model's listing down to exactly greeter, hiding calculator and all bundled/plugin skills. End-to-end filter confirmed working with real API calls.

'all' and omitted both mean 'no filter' at the wire level, so only send
the field when it is an explicit list. Keeps the CLI control schema as a
plain string[] (which the zod-to-proto pipeline can represent) while the
'all' sentinel remains at the Python API surface for ergonomics.
@jhonfarrell
Copy link
Copy Markdown

My current feature requires this PR

IgorTavcar added a commit to IgorTavcar/claude-agent-sdk-python that referenced this pull request Apr 10, 2026
- anthropics#806: setting_sources=[] truthiness fix
- anthropics#803: betas=[]/plugins=[] truthiness fix
- anthropics#786: ThinkingBlock missing signature crash fix
- anthropics#790: suppress ProcessError when result already received
- anthropics#658: capture real stderr in ProcessError
- anthropics#791: suppress stale task notifications between turns
- anthropics#763: guard malformed CLAUDE_CODE_STREAM_CLOSE_TIMEOUT env var
- anthropics#805: delete_session() cascades subagent transcript dir
- anthropics#804: top-level skills option on ClaudeAgentOptions
- anthropics#691: PostCompact hook event type support

479 tests passing, mypy clean, ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Support skills loading scope

3 participants